Video Thumbnail
4:31
3:09

Solution: Better Discounts


Manuel Escalona

Here my solution, assuming only 1 discount can be applied to the cart.

from dataclasses import dataclass, field
from decimal import Decimal

class ItemNotFoundException(Exception):
pass

@dataclass
class Item:
name: str
price: Decimal
quantity: int

@property
def subtotal(self) -> Decimal:
return self.price * self.quantity

@dataclass
class Discount:
code: str = ""
type: str = ""
amount: Decimal = Decimal(0)

DISCOUNTS = {
"SAVE10": Discount(code="SAVE10", type="percentage", amount=Decimal("0.1")),
"5BUCKSOFF": Discount(code="5BUCKSOFF", type="fixed", amount=Decimal("5.00") ),
"FREESHIPPING": Discount(code="FREESHIPPING", type="fixed", amount=Decimal("2.00")),
"BLKFRIDAY": Discount(code="BLKFRIDAY", type="percentage", amount=Decimal("0.2"))
}

@dataclass
class ShoppingCart:
items: list[Item] = field(default_factory = list)
disct: Discount = field(default_factory = lambda: Discount())

def add_item(self, item: Item) -> None:
self.items.append(item)

def remove_item(self, item_name: str) -> None:
found_item = self.find_item(item_name)
self.items.remove(found_item)

def find_item(self, item_name: str) -> Item:
for item in self.items:
if item.name == item_name:
return item
raise ItemNotFoundException(f"Item '{item_name}' not found.")

@property
def subtotal(self) -> Decimal:
return Decimal(sum(item.subtotal for item in self.items))

@property
def discount(self) -> Decimal:
if self.disct.code == "" or self.disct.code not in DISCOUNTS:
return Decimal("0")
else:
if DISCOUNTS[self.disct.code].type == "percentage":
return self.subtotal * DISCOUNTS[self.disct.code].amount
elif DISCOUNTS[self.disct.code].type == "fixed":
return DISCOUNTS[self.disct.code].amount
else:
return Decimal("0")


@property
def total(self) -> Decimal:
return self.subtotal - self.discount

def display(self) -> None:
# Print the cart
print("Shopping Cart:")
print(f"{'Item':<10}{'Price':>10}{'Qty':>7}{'Total':>13}")
for item in self.items:
print(
f"{item.name:<12}${item.price:>7.2f}{item.quantity:>7} ${item.subtotal:>7.2f}"
)
print("=" * 40)
print(f"Subtotal: ${self.subtotal:>7.2f}")
print(f"Discount: ${self.discount:>7.2f}")
print(f"Total: ${self.total:>7.2f}")

def main() -> None:
# Create a shopping cart and add some items to it
cart = ShoppingCart(
items=[
Item("Apple", Decimal("1.50"), 10),
Item("Banana", Decimal("2.00"), 2),
Item("Pizza", Decimal("11.90"), 5),
],
disct = Discount("5BUCKSOFF")
)

cart.display()

if __name__ == "__main__":
main()

REPLY
Andreas [ArjanCodes Team]

This is a nice solution! However, the assumption is not correct. A cart should be able to have multiple discounts, try updating the code so it can handle those scenarios as well :)

REPLY
Manuel Escalona

Thanks Andreas for your suggestion. Here is the updated code that supports multiple discounts:

from dataclasses import dataclass, field
from decimal import Decimal

class ItemNotFoundException(Exception):
pass

@dataclass
class Item:
name: str
price: Decimal
quantity: float

@property
def subtotal(self) -> Decimal:
return self.price * self.quantity

@dataclass
class Discount:
type: str = ""
amount: Decimal = Decimal(0)

DISCOUNTS = {
"SAVE10": Discount(type="percentage", amount=Decimal("0.1")),
"5BUCKSOFF": Discount(type="fixed", amount=Decimal("5.00") ),
"FREESHIPPING": Discount(type="fixed", amount=Decimal("2.00")),
"BLKFRIDAY": Discount(type="percentage", amount=Decimal("0.2"))
}

@dataclass
class ShoppingCart:
items: list[Item] = field(default_factory = list)
disct: list[str] = field(default_factory = list)

def add_item(self, item: Item) -> None:
self.items.append(item)

def remove_item(self, item_name: str) -> None:
found_item = self.find_item(item_name)
self.items.remove(found_item)

def find_item(self, item_name: str) -> Item:
for item in self.items:
if item.name == item_name:
return item
raise ItemNotFoundException(f"Item '{item_name}' not found.")

@property
def subtotal(self) -> Decimal:
return Decimal(sum(item.subtotal for item in self.items))

@property
def discount(self) -> Decimal:
total_discount = Decimal("0")
for code in self.disct:
if code in DISCOUNTS:
if DISCOUNTS[code].type == "percentage":
total_discount += DISCOUNTS[code].amount * self.subtotal
elif DISCOUNTS[code].type == "fixed":
total_discount += DISCOUNTS[code].amount

return total_discount


@property
def total(self) -> Decimal:
return self.subtotal - self.discount

def display(self) -> None:
# Print the cart
print("Shopping Cart:")
print(f"{'Item':<10}{'Price':>10}{'Qty':>7}{'Total':>13}")
for item in self.items:
print(
f"{item.name:<12}${item.price:>7.2f}{item.quantity:>7} ${item.subtotal:>7.2f}"
)
print("=" * 40)
print(f"Subtotal: ${self.subtotal:>7.2f}")
print(f"Discount: ${self.discount:>7.2f}")
print(f"Total: ${self.total:>7.2f}")

def main() -> None:
# Create a shopping cart and add some items to it
cart = ShoppingCart(
items=[
Item("Apple", Decimal("1.50"), 10),
Item("Banana", Decimal("2.00"), 2),
Item("Pizza", Decimal("11.90"), 5),
],
disct = ["SAVE10","FREESHIPPING","5BUCKSOFF"]
)

cart.display()

if __name__ == "__main__":
main()

REPLY
Andreas [ArjanCodes Team]

Great! Looks good, nice solution :D

REPLY
Raegan Barker

A little bit of scope creep, because it's the weekend and why not? :)

We might not want to allow discounting more than the subtotal, so potentially the discount property in ShoppingCart in the solution could return something like this instead:

return total_discount if total_discount <= self.subtotal else self.subtotal

REPLY
Arjan Egges

Hi Raegan, good idea (if you like large discounts ;) ).

REPLY
Eugenio Grimoldi

Looking from your video I think I overcomplicated my solution :)

I wrote an Enum for the discounts and their value, two functions for flat or percentage discounts and then a "strategy" function that link a specific DiscountCode to flat or percentage discount.
Two drawbacks I found are:
- I need to add to the Enum class and the strategy function every new discount code
- On the flat discount I had to add the total, although it is not used, but was needed to use a compact function in the strategy function.

Code below

def percentage_discount(discount: Decimal, total: Decimal) -> Decimal:
return total * discount

def flat_discount(discount: Decimal, total: Decimal) -> Decimal:
return discount

class DiscountCode(Enum):
SAVE10 = Decimal(0.1)
FIVEBUCKSOFF = Decimal(5)
FREESHIPPING = Decimal(2)
BLACKFRIDAY = Decimal(0.2)
DEFAULT = Decimal(0)

DiscountFn = Callable[[Decimal], Decimal]

def discount_strategy(code: DiscountCode) -> DiscountFn:
mapping = {
DiscountCode.BLACKFRIDAY: percentage_discount,
DiscountCode.SAVE10: percentage_discount,
DiscountCode.FIVEBUCKSOFF: flat_discount,
DiscountCode.FREESHIPPING: flat_discount,
DiscountCode.DEFAULT: flat_discount,
}
return partial(
mapping.get(code, mapping[DiscountCode.DEFAULT]), discount=code.value
)

@dataclass
class ShoppingCart:
items: list[Item] = field(default_factory=list)
discounts: list[DiscountCode] = field(default_factory=list)
def apply_discount(self, discount: DiscountCode) -> None:
self.discounts.append(discount)
[...]
@property
def discount(self) -> Decimal:
discount = Decimal(0)
for discount_code in self.discounts:
discount_fn = discount_strategy(discount_code)
discount += discount_fn(total=self.subtotal)

return discount
[...]

REPLY
Arjan Egges

Thanks for posting your code! It's something that I notice myself as well when writing code: initially I start writing code that's too complex, but then it serves as a great starting point for refactoring it into something much simpler. It seems that I need to write the complex code first to help me understand the problem before I can come up with a better, simpler solution.

REPLY
Mark Todisco

Hi Arjan, I like your solution! One difference I noticed in my code was making the Discount object "callable". However, I do think allowing a list of discounts is a better solution.

class DiscountType(enum.Enum):
VALUE = enum.auto()
PERCENTAGE = enum.auto()

@dataclass
class Discount:
amount: Decimal
type: DiscountType

def __call__(self, total: Decimal) -> Decimal:
if self.type == DiscountType.VALUE:
return total - self.amount
return total * (1 - self.amount)

DISCOUNT_MAP = {
"SAVE10": Discount(Decimal("0.1"), DiscountType.PERCENTAGE),
"5BUCKSOFF": Discount(Decimal("5.00"), DiscountType.VALUE),
"FREESHIPPING": Discount(Decimal("2.00"), DiscountType.VALUE),
"BLKFRIDAY": Discount(Decimal("0.2"), DiscountType.PERCENTAGE),
}

def discount_factory(code: str) -> Discount:
discount = DISCOUNT_MAP.get(code, None)
if discount is None:
raise NameError(f"{code} is not a valid discount code")
return discount

@dataclass
class ShoppingCart:
items: list[Item] = field(default_factory=list)
discount: Discount | None = None

@property
def total(self) -> Decimal:
if self.discount is None:
return self.subtotal
return self.discount(self.subtotal)

REPLY
Arjan Egges

Hi Mark, that's also a nice solution!

REPLY
Loïc Riegel

I used a dict too, but mapping strings (discount codes) to functions that apply discounts. In a sense, I don't need a Discount class, which might make the code a little simpler, but you convinced me in your video when you said that we could read the "amount" and "percentage" from a database. We can't use that with functions.

For those interested:
GetDiscountFn = typing.Callable[[Decimal], Decimal]
def get_discount(base_price: Decimal, discount_code: str) -> Decimal:
DISCOUNT_FNS: dict[str, GetDiscountFn] = {
"SAVE10": discount_five_bucks,
"5BUCKSOFF": discount_ten_percent,
"FREESHIPPING": discount_free_shipping,
"BLKFRIDAY": discount_black_friday,
}
if discount_code not in DISCOUNT_FNS:
raise UnknownDiscountCode(discount_code)
discount_fn = DISCOUNT_FNS[discount_code]
return discount_fn(base_price)

REPLY
Sylvain Payot

Took a similar approach. what i like about the Callable approach is that it further decouples Discounts from ShoppingCarts. ShoppingCart doesn't need to know anything about how discounts are applied, only that it can get a discount value when it applies that function on the cart subtotal.
It also allows to support non-linear discounts (e.g. a tier-based discount that looks like a step function, depending on some subtotal thresholds).

REPLY
Andreas [ArjanCodes Team]

Agreed! It is a very useful and elegant way of solving the challenge!

REPLY
Philipp Walter

Same here, I implemented the discounts in a dict with Callables:

AbsolutDiscountCalculation = Callable[[Decimal],Decimal]

discounts: dict[str,AbsolutDiscountCalculation] = field(default_factory=dict)

which are finally just added as lambda functions which are of course not reusable in this way:

discounts={
"SAVE10": lambda subtotal: subtotal * Decimal("0.1"),
"5BUCKSOFF": lambda _: Decimal("5.00"),
"FREESHIPPING": lambda _: Decimal("2.00"),
"BLKFRIDAY": lambda subtotal: subtotal * Decimal("0.2"),
},

@property
def discount(self) -> Decimal:
if self.discount_code in self.discounts:
return self.discounts[self.discount_code](self.subtotal)
else:
return Decimal("0.00")

Inspired by a current task in the job, where a similar approach is necessary to implement more or less complicated calculations based on a key, like Sylvain mentioned.

REPLY
Andreas [ArjanCodes Team]

Nice to see some lambda functions!

REPLY
Show More